# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# This Source Code Form is "Incompatible With Secondary Licenses", as
# defined by the Mozilla Public License, v. 2.0.

package Bugzilla::Config::Common;

use 5.14.0;
use strict;
use warnings;

use Email::Address;
use Socket;

use Bugzilla::Util;
use Bugzilla::Constants;
use Bugzilla::Field;
use Bugzilla::Group;
use Bugzilla::Status;

use parent qw(Exporter);
@Bugzilla::Config::Common::EXPORT
  = qw(check_multi check_numeric check_regexp check_group
  check_sslbase check_priority check_severity check_platform
  check_opsys check_shadowdb check_urlbase check_user_verify_class
  check_ip check_mail_delivery_method check_notification
  check_bug_status check_smtp_auth check_theschwartz_available
  check_maxattachmentsize check_email check_smtp_ssl
  check_comment_taggers_group check_smtp_server check_resolution

  change_use_email_as_login change_duplicate_or_move_bug_status
);

# Checking functions for the various values

sub check_multi {
  my ($value, $param) = (@_);

  if ($param->{'type'} eq "s") {
    unless (scalar(grep { $_ eq $value } (@{$param->{'choices'}}))) {
      return
        "Invalid choice '$value' for single-select list param '$param->{'name'}'";
    }

    return "";
  }
  elsif ($param->{'type'} eq 'm' || $param->{'type'} eq 'o') {
    if (ref($value) ne "ARRAY") {
      $value = [split(',', $value)];
    }
    foreach my $chkParam (@$value) {
      unless (scalar(grep { $_ eq $chkParam } (@{$param->{'choices'}}))) {
        return
          "Invalid choice '$chkParam' for multi-select list param '$param->{'name'}'";
      }
    }

    return "";
  }
  else {
    return "Invalid param type '$param->{'type'}' for check_multi(); "
      . "contact your Bugzilla administrator";
  }
}

sub check_numeric {
  my ($value) = (@_);
  if ($value !~ /^[0-9]+$/) {
    return "must be a numeric value";
  }
  return "";
}

sub check_regexp {
  my ($value) = (@_);
  eval {qr/$value/};
  return $@;
}

sub check_email {
  my ($value) = @_;
  if ($value !~ $Email::Address::mailbox) {
    return "must be a valid email address.";
  }
  return "";
}

sub check_sslbase {
  my $url = shift;
  if ($url ne '') {
    if ($url !~ m#^https://([^/]+).*/$#) {
      return "must be a legal URL, that starts with https and ends with a slash";
    }
    my $host = $1;

    # Fall back to port 443 if for some reason getservbyname() fails.
    my $port = getservbyname('https', 'tcp') || 443;
    if ($host =~ /^(.+):(\d+)$/) {
      $host = $1;
      $port = $2;
    }
    local *SOCK;
    my $proto = getprotobyname('tcp');
    socket(SOCK, PF_INET, SOCK_STREAM, $proto);
    my $iaddr = inet_aton($host) || return "The host $host cannot be resolved";
    my $sin = sockaddr_in($port, $iaddr);
    if (!connect(SOCK, $sin)) {
      return "Failed to connect to $host:$port ($!); unable to enable SSL";
    }
    close(SOCK);
  }
  return "";
}

sub check_ip {
  my $inbound_proxies = shift;
  my @proxies = split(/[\s,]+/, $inbound_proxies);
  foreach my $proxy (@proxies) {
    validate_ip($proxy) || return "$proxy is not a valid IPv4 or IPv6 address";
  }
  return "";
}

sub check_priority {
  my ($value) = (@_);
  my $legal_priorities = get_legal_field_values('priority');
  if (!grep($_ eq $value, @$legal_priorities)) {
    return "Must be a legal priority value: one of "
      . join(", ", @$legal_priorities);
  }
  return "";
}

sub check_severity {
  my ($value) = (@_);
  my $legal_severities = get_legal_field_values('bug_severity');
  if (!grep($_ eq $value, @$legal_severities)) {
    return "Must be a legal severity value: one of "
      . join(", ", @$legal_severities);
  }
  return "";
}

sub check_platform {
  my ($value) = (@_);
  my $legal_platforms = get_legal_field_values('rep_platform');
  if (!grep($_ eq $value, '', @$legal_platforms)) {
    return "Must be empty or a legal platform value: one of "
      . join(", ", @$legal_platforms);
  }
  return "";
}

sub check_opsys {
  my ($value) = (@_);
  my $legal_OS = get_legal_field_values('op_sys');
  if (!grep($_ eq $value, '', @$legal_OS)) {
    return "Must be empty or a legal operating system value: one of "
      . join(", ", @$legal_OS);
  }
  return "";
}

sub check_bug_status {
  my $bug_status = shift;
  my @closed_bug_statuses = map { $_->name } closed_bug_statuses();
  if (!grep($_ eq $bug_status, @closed_bug_statuses)) {
    return "Must be a valid closed status: one of "
      . join(', ', @closed_bug_statuses);
  }
  return "";
}

sub check_resolution {
  my $resolution = shift;
  my $resolution_field = Bugzilla::Field->new({name => 'resolution', cache => 1});

  # The empty resolution is included - it represents "no value"
  my @resolutions = map { $_->name } @{$resolution_field->legal_values};

  if (!grep($_ eq $resolution, @resolutions)) {
    return "Must be blank or a valid resolution: one of "
      . join(', ', @resolutions);
  }
  return "";
}

sub check_group {
  my $group_name = shift;
  return "" unless $group_name;
  my $group = new Bugzilla::Group({'name' => $group_name});
  unless (defined $group) {
    return "Must be an existing group name";
  }
  return "";
}

sub check_shadowdb {
  my ($value) = (@_);
  $value = trim($value);
  if ($value eq "") {
    return "";
  }

  if (!Bugzilla->params->{'shadowdbhost'}) {
    return "You need to specify a host when using a shadow database";
  }

  # Can't test existence of this because ConnectToDatabase uses the param,
  # but we can't set this before testing....
  # This can really only be fixed after we can use the DBI more openly
  return "";
}

sub check_urlbase {
  my ($url) = (@_);
  if ($url && $url !~ m:^http.*/$:) {
    return "must be a legal URL, that starts with http and ends with a slash.";
  }
  return "";
}

sub check_user_verify_class {

  # doeditparams traverses the list of params, and for each one it checks,
  # then updates. This means that if one param checker wants to look at
  # other params, it must be below that other one. So you can't have two
  # params mutually dependent on each other.
  # This means that if someone clears the LDAP config params after setting
  # the login method as LDAP, we won't notice, but all logins will fail.
  # So don't do that.

  my $params = Bugzilla->params;
  my ($list, $entry) = @_;
  $list || return 'You need to specify at least one authentication mechanism';
  for my $class (split /,\s*/, $list) {
    my $res = check_multi($class, $entry);
    return $res if $res;
    if ($class eq 'RADIUS') {
      if (!Bugzilla->feature('auth_radius')) {
        return "RADIUS support is not available. Run checksetup.pl"
          . " for more details";
      }
      return "RADIUS servername (RADIUS_server) is missing"
        if !$params->{"RADIUS_server"};
      return "RADIUS_secret is empty" if !$params->{"RADIUS_secret"};
    }
    elsif ($class eq 'LDAP') {
      if (!Bugzilla->feature('auth_ldap')) {
        return "LDAP support is not available. Run checksetup.pl" . " for more details";
      }
      return "LDAP servername (LDAPserver) is missing" if !$params->{"LDAPserver"};
      return "LDAPBaseDN is empty"                     if !$params->{"LDAPBaseDN"};
    }
  }
  return "";
}

sub check_mail_delivery_method {
  my $check = check_multi(@_);
  return $check if $check;
  my $mailer = shift;
  if ($mailer eq 'Sendmail' and ON_WINDOWS) {

    # look for sendmail.exe
    return "Failed to locate " . SENDMAIL_EXE unless -e SENDMAIL_EXE;
  }
  return "";
}

sub check_maxattachmentsize {
  my $check = check_numeric(@_);
  return $check if $check;
  my $size = shift;
  my $dbh  = Bugzilla->dbh;
  if ($dbh->isa('Bugzilla::DB::Mysql')) {
    my (undef, $max_packet)
      = $dbh->selectrow_array(q{SHOW VARIABLES LIKE 'max\_allowed\_packet'});
    my $byte_size = $size * 1024;
    if ($max_packet < $byte_size) {
      return
          "You asked for a maxattachmentsize of $byte_size bytes,"
        . " but the max_allowed_packet setting in MySQL currently"
        . " only allows packets up to $max_packet bytes";
    }
  }
  return "";
}

sub check_notification {
  my $option = shift;
  my @current_version
    = (BUGZILLA_VERSION =~ m/^(\d+)\.(\d+)(?:(rc|\.)(\d+))?\+?$/);
  if ($current_version[1] % 2 && $option eq 'stable_branch_release') {
    return
        "You are currently running a development snapshot, and so your "
      . "installation is not based on a branch. If you want to be notified "
      . "about the next stable release, you should select "
      . "'latest_stable_release' instead";
  }
  if ($option ne 'disabled' && !Bugzilla->feature('updates')) {
    return "Some Perl modules are missing to get notifications about "
      . "new releases. See the output of checksetup.pl for more information";
  }
  return "";
}

sub check_smtp_server {
  my $host = shift;
  my $port;

  return '' unless $host;

  if ($host =~ /:/) {
    ($host, $port) = split(/:/, $host, 2);
    unless ($port && detaint_natural($port)) {
      return "Invalid port. It must be an integer (typically 25, 465 or 587)";
    }
  }
  trick_taint($host);

  # Let's first try to connect using SSL. If this fails, we fall back to
  # an unencrypted connection.
  foreach my $method (['Net::SMTP::SSL', 465], ['Net::SMTP', 25]) {
    my ($class, $default_port) = @$method;
    next if $class eq 'Net::SMTP::SSL' && !Bugzilla->feature('smtp_ssl');
    eval "require $class";
    my $smtp = $class->new($host, Port => $port || $default_port, Timeout => 5);
    if ($smtp) {

      # The connection works!
      $smtp->quit;
      return '';
    }
  }
  return "Cannot connect to $host" . ($port ? " using port $port" : "");
}

sub check_smtp_auth {
  my $username = shift;
  if ($username and !Bugzilla->feature('smtp_auth')) {
    return "SMTP Authentication is not available. Run checksetup.pl for"
      . " more details";
  }
  return "";
}

sub check_smtp_ssl {
  my $use_ssl = shift;
  if ($use_ssl && !Bugzilla->feature('smtp_ssl')) {
    return "SSL support is not available. Run checksetup.pl for more details";
  }
  return "";
}

sub check_theschwartz_available {
  my $use_queue = shift;
  if ($use_queue && !Bugzilla->feature('jobqueue')) {
    return
        "Using the job queue requires that you have certain Perl"
      . " modules installed. See the output of checksetup.pl"
      . " for more information";
  }
  return "";
}

sub check_comment_taggers_group {
  my $group_name = shift;
  if ($group_name && !Bugzilla->feature('jsonrpc')) {
    return "Comment tagging requires installation of the JSONRPC feature";
  }
  return check_group($group_name);
}

# Change handler functions for various parameters

# If use_email_as_login is turned on, update all login names to be email
# addresses.
sub change_use_email_as_login {
  my $newvalue = shift;
  if ($newvalue) {
    Bugzilla->dbh->do('UPDATE profiles SET login_name = email');
  }
}

sub change_duplicate_or_move_bug_status {
  my $newvalue = shift;
  Bugzilla::Status::add_missing_bug_status_transitions($newvalue);
}

# OK, here are the parameter definitions themselves.
#
# Each definition is a hash with keys:
#
# name     - name of the param
# desc     - description of the param (for editparams.cgi)
# type     - see below
# choices  - (optional) see below
# default  - default value for the param
# checker  - (optional) checking function for validating parameter entry.
#            It is called with the value of the param as the first arg
#            and a reference to the param's hash as the second argument.
# onchange - (optional) handling function for parameter changes.
#            The argument is the new value of the param.
#
# The type value can be one of the following:
#
# t -- A short text entry field (suitable for a single line)
# p -- A short text entry field (as with type = 't'), but the string is
#      replaced by asterisks (appropriate for passwords)
# l -- A long text field (suitable for many lines)
# b -- A boolean value (either 1 or 0)
# m -- A list of values, with many selectable (shows up as a select box)
#      To specify the list of values, make the 'choices' key be an array
#      reference of the valid choices. The 'default' key should be a string
#      with a list of selected values (as a comma-separated list), i.e.:
#       {
#         name => 'multiselect',
#         desc => 'A list of options, choose many',
#         type => 'm',
#         choices => [ 'a', 'b', 'c', 'd' ],
#         default => [ 'a', 'd' ],
#         checker => \&check_multi
#       }
#
#      Here, 'a' and 'd' are the default options, and the user may pick any
#      combination of a, b, c, and d as valid options.
#
#      &check_multi should always be used as the param verification function
#      for list (single and multiple) parameter types.
#
# s -- A list of values, with one selectable (shows up as a select box)
#      To specify the list of values, make the 'choices' key be an array
#      reference of the valid choices. The 'default' key should be one of
#      those values, i.e.:
#       {
#         name => 'singleselect',
#         desc => 'A list of options, choose one',
#         type => 's',
#         choices => [ 'a', 'b', 'c' ],
#         default => 'b',
#         checker => \&check_multi
#       }
#
#      Here, 'b' is the default option, and 'a' and 'c' are other possible
#      options, but only one at a time!
#
#      &check_multi should always be used as the param verification function
#      for list (single and multiple) parameter types.
#
# o -- A list of values, orderable, and with many selectable (shows up as a
#      JavaScript-enhanced select box if JavaScript is enabled, and a text
#      entry field if not)
#      Set up in the same way as type s. If the default has multiple values,
#      then they must be concatenated, separated by a comma.
#      For instance: default => 'a,c'.

sub get_param_list {
  return;
}

1;

__END__

=head1 NAME

Bugzilla::Config::Common - Parameter checking functions

=head1 DESCRIPTION

All parameter checking functions are called with two parameters: the value to 
check, and a hash with the details of the param (type, default etc.) as defined
in the relevant F<Bugzilla::Config::*> package.

=head2 Functions

=over

=item C<check_multi>

Checks that a multi-valued parameter (ie types C<s>, C<o> or C<m>) satisfies
its constraints.

=item C<check_numeric>

Checks that the value is a valid number

=item C<check_regexp>

Checks that the value is a valid regexp

=item C<check_comment_taggers_group>

Checks that the required modules for comment tagging are installed, and that a
valid group is provided.

=item C<change_use_email_as_login>

Change handler for "use_email_as_login" parameter - if param is changed to
true, updates login_name field to be the value of the email field for all
users.

=item C<change_duplicate_or_move_bug_status>

Change handler for "duplicate_or_move_bug_status" parameter - if param is
changed, insert all missing transitions to the new bug status.

=back

=head1 B<Methods in need of POD>

=over

=item check_notification

=item check_priority

=item check_ip

=item check_user_verify_class

=item check_bug_status

=item check_resolution

=item check_shadowdb

=item check_smtp_server

=item check_smtp_auth

=item check_urlbase

=item check_email

=item get_param_list

=item check_maxattachmentsize

=item check_group

=item check_opsys

=item check_platform

=item check_severity

=item check_sslbase

=item check_mail_delivery_method

=item check_theschwartz_available

=item check_smtp_ssl

=back
